# -*- coding: utf-8 -*-
"""
Tkinter UI for kb189 login + betting workflow
Python 3.9.13

能力：
- 多站点 JSON-Array 配置（可编辑/保存/另存）
- 站点/账号选择，登录状态表
- “新增站点”“新增账号”小面板
- 投注指令输入/导入，真实下注（GetBetFullData -> MemberBet）
- 投注间隔（base±1s抖动）、下注金额（可保存）
- 已投注列表、单号与组合统计
- ✅ 暂停/恢复投注
- ✅ 已投注记录自动保存/恢复 & 可编辑（保存/应用后会刷新去重缓存）
"""
import json
import threading
import time
import random
import os
from dataclasses import dataclass, field
from typing import List, Tuple, Dict, Optional

import tkinter as tk
from tkinter import ttk, filedialog, messagebox

import kb189_full_flow_strict as kbf  # 登录与下注 API

# ------------------ 配置数据模型 ------------------
@dataclass
class AccountCfg:
    name: str
    account: str
    password: str
    base_origin: str = ""
    safecode: str = ""
    retries: int = 5
    verify_ssl: bool = True

@dataclass
class SiteCfg:
    site_name: str
    base_origin: str
    safecode: str
    retries: int = 5
    verify_ssl: bool = True
    bet_interval_sec: float = 6.0
    bet_amount: int = 1            # 下注金额
    handicap_id: int = 1           # 盘口
    group_id: int = 47             # 玩法组
    accounts: List[AccountCfg] = field(default_factory=list)

@dataclass
class SessionState:
    session: Optional[kbf.requests.Session] = None
    draw_base: Optional[str] = None
    app_index_url: Optional[str] = None
    logged_in: bool = False
    last_login_ts: float = 0.0
    last_error: str = ""

# ------------------ 配置加载/保存（兼容旧版） ------------------
def _dict_to_sitecfg(d: dict) -> SiteCfg:
    site = SiteCfg(
        site_name=d.get("site_name") or d.get("name") or "site",
        base_origin=d["base_origin"],
        safecode=d["safecode"],
        retries=int(d.get("retries", 5)),
        verify_ssl=bool(d.get("verify_ssl", True)),
        bet_interval_sec=float(d.get("bet_interval_sec", 6.0)),
        bet_amount=int(d.get("bet_amount", 1)),
        handicap_id=int(d.get("handicap_id", 1)),
        group_id=int(d.get("group_id", 47)),
        accounts=[]
    )
    for a in d.get("accounts", []):
        site.accounts.append(AccountCfg(
            name=a.get("name") or a.get("account") or "account",
            account=a["account"],
            password=a["password"],
            base_origin=site.base_origin,
            safecode=site.safecode,
            retries=site.retries,
            verify_ssl=site.verify_ssl
        ))
    return site

def load_sites_config(path: str) -> List[SiteCfg]:
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)
    sites: List[SiteCfg] = []
    if isinstance(data, list):
        for d in data:
            sites.append(_dict_to_sitecfg(d))
    elif isinstance(data, dict):
        # 兼容旧版：单对象
        base = {
            "site_name": data.get("site_name") or data.get("name") or "site",
            "base_origin": data["base_origin"],
            "safecode": data["safecode"],
            "retries": data.get("retries", 5),
            "verify_ssl": data.get("verify_ssl", True),
            "bet_interval_sec": data.get("bet_interval_sec", 6.0),
            "bet_amount": data.get("bet_amount", 1),
            "handicap_id": data.get("handicap_id", 1),
            "group_id": data.get("group_id", 47),
            "accounts": data.get("accounts") or [{
                "name": data.get("name") or data.get("account") or "account",
                "account": data["account"],
                "password": data["password"],
            }],
        }
        sites.append(_dict_to_sitecfg(base))
    else:
        raise ValueError("配置文件格式不合法：应为 JSON 数组或对象")
    return sites

def sites_to_jsonable(sites: List[SiteCfg]) -> list:
    out = []
    for s in sites:
        out.append({
            "site_name": s.site_name,
            "base_origin": s.base_origin,
            "safecode": s.safecode,
            "retries": s.retries,
            "verify_ssl": s.verify_ssl,
            "bet_interval_sec": s.bet_interval_sec,
            "bet_amount": s.bet_amount,
            "handicap_id": s.handicap_id,
            "group_id": s.group_id,
            "accounts": [{"name": a.name, "account": a.account, "password": a.password} for a in s.accounts]
        })
    return out

# ------------------ 投注指令解析 ------------------
def parse_bet_lines(text: str) -> List[Tuple[int, Tuple[int, int, int]]]:
    lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    out: List[Tuple[int, Tuple[int, int, int]]] = []
    if not lines:
        return out

    def line_to_tuple(ln: str) -> Optional[Tuple[int, Tuple[int, int, int]]]:
        for delim in [",", "\t"]:
            ln = ln.replace(delim, " ")
        parts = [p for p in ln.split(" ") if p]
        try:
            if not parts or (not parts[0].isdigit()):
                return None
            idx = int(parts[0])
            nums = []
            for p in parts[1:]:
                if p.isdigit():
                    nums.append(int(p))
                    if len(nums) == 3:
                        break
            if len(nums) != 3:
                return None
            n1, n2, n3 = nums
            if not all(1 <= n <= 49 for n in nums):
                return None
            return (idx, (n1, n2, n3))
        except Exception:
            return None

    first = line_to_tuple(lines[0])
    start_idx = 0 if first else 1
    for ln in lines[start_idx:]:
        tup = line_to_tuple(ln)
        if tup:
            out.append(tup)
    return out

# ------------------ 登录封装（直接用模块 login_flow） ------------------
def login_flow(ac: AccountCfg, log_fn) -> SessionState:
    ss = SessionState()
    try:
        lr = kbf.login_flow(
            base_origin=ac.base_origin, safecode=ac.safecode,
            account=ac.account, password=ac.password,
            retries=ac.retries, verify_ssl=ac.verify_ssl, log_fn=log_fn
        )
        if lr:
            ss.session = lr.session
            ss.draw_base = lr.draw_base
            ss.app_index_url = lr.app_index_url
            ss.logged_in = True
            ss.last_login_ts = time.time()
        else:
            ss.last_error = "login_flow failed"
    except Exception as e:
        ss.last_error = str(e)
    return ss

# ------------------ 真实下注客户端 ------------------
class RealBetClient:
    def __init__(self, site: SiteCfg, account: AccountCfg, state: SessionState, log_fn):
        self.site = site
        self.account = account
        self.state = state
        self.log = log_fn

    def ensure_login(self) -> bool:
        if self.state.logged_in and (time.time() - self.state.last_login_ts < 60 * 30):
            return True
        self.log("[bet] Session 过期/未登录，正在重新登录 ...")
        new_state = login_flow(self.account, self.log)
        if new_state.logged_in:
            self.state.session = new_state.session
            self.state.draw_base = new_state.draw_base
            self.state.app_index_url = new_state.app_index_url
            self.state.logged_in = True
            self.state.last_login_ts = new_state.last_login_ts
            return True
        self.state.logged_in = False
        self.log(f"[bet] 重新登录失败：{new_state.last_error}")
        return False

    def _get_odds_str(self, odds_json: Dict, bet_type_id: int, bet_items: List[int]) -> str:
        try:
            odds_list = odds_json["Data"]["Odds"]
            match = next(
                (o for o in odds_list if o.get("BetTypeId") == bet_type_id and int(o.get("BetItemId")) in bet_items),
                None
            )
            if match:
                return f'{match.get("Odds1")},{match.get("Odds2")}'
        except Exception:
            pass
        return "20,120"  # 回退

    def place_bet(self, serial: int, nums: Tuple[int, int, int]) -> Dict:
        """真实下注：GetBetFullData -> MemberBet"""
        if not self.ensure_login():
            return {"Status": 0, "Message": "ReloginFailed"}

        # 1) 获取赔率
        gd = kbf.get_bet_full_data(
            self.state.session, self.state.draw_base, self.state.app_index_url,
            handicap_id=self.site.handicap_id, group_id=self.site.group_id,
            max_tries=self.account.retries
        )
        if str(gd.get("Status")) != "1":
            if "登录" in gd.get("Message", "") or "登入" in gd.get("Message", ""):
                self.state.logged_in = False
            return {"Status": 0, "Message": f"GetBetFullDataFailed: {gd.get('Message','')}"}

        odds_str = self._get_odds_str(gd, bet_type_id=51, bet_items=list(nums))

        # 2) 拼 BetData 并下注
        payload = kbf.build_bet_payload_triple(list(nums), self.site.bet_amount, odds_str,
                                               bet_type_id=51, play_type_name="三中二")
        res = kbf.post_member_bet(
            self.state.session, self.state.draw_base, self.state.app_index_url,
            payload, handicap_id=self.site.handicap_id, max_tries=self.account.retries
        )

        ok_outer = str(res.get("Status")) == "1"
        data = res.get("Data") if isinstance(res.get("Data"), dict) else {}
        ok_inner = str(data.get("Status")) == "1" if data else False

        if ok_outer and ok_inner:
            period = ""
            try:
                period = gd["Data"]["Period"]["PeriodCode"]
            except Exception:
                pass
            order_hint = f"SERVER-{period}-{serial}" if period else f"SERVER-{serial}"
            return {"Status": 1, "Message": data.get("Message", "OK"), "OrderId": order_hint, "Raw": res}
        else:
            msg = (data.get("Message") if data else res.get("Message")) or "BetFailed"
            if ("登录" in msg) or ("登入" in msg):
                self.state.logged_in = False
            return {"Status": 0, "Message": msg, "Raw": res}

# ------------------ 工具：已投注记录解析/默认路径 ------------------
def default_placed_path(site_name: str, acc_name: str) -> str:
    safe_site = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in site_name)
    safe_acc = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in acc_name)
    return f"placed_{safe_site}_{safe_acc}.txt"

def parse_done_line(line: str) -> Optional[Tuple[int, Tuple[int, int, int]]]:
    """支持格式：<serial>\tN1 N2 N3 [\t#...order...]"""
    s = line.strip()
    if not s or s.startswith("//") or s.startswith("# "):
        return None
    parts = s.split("\t")
    if not parts:
        return None
    try:
        serial = int(parts[0].strip())
    except Exception:
        return None
    nums_part = ""
    if len(parts) >= 2:
        nums_part = parts[1].strip()
    else:
        return None
    nums = [int(p) for p in nums_part.replace(",", " ").split() if p.isdigit()]
    if len(nums) != 3:
        return None
    if not all(1 <= n <= 49 for n in nums):
        return None
    return (serial, (nums[0], nums[1], nums[2]))

# ------------------ Tk 应用 ------------------
class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("KB189 投注助手 (Tk)")
        self.geometry("1360x980")

        self.sites: List[SiteCfg] = []
        self.accounts_by_site: Dict[str, List[AccountCfg]] = {}
        self.states: Dict[Tuple[str, str], SessionState] = {}

        # 当前选择下的缓存
        self.already_set = set()  # { (serial, (a,b,c)) }
        self.num_counts: Dict[int, int] = {}
        self.triplet_counts: Dict[Tuple[int, int, int], int] = {}

        # 投注运行控制
        self.bet_thread: Optional[threading.Thread] = None
        self.pause_event = threading.Event()  # set=运行, clear=暂停
        self.pause_event.set()
        self.bet_running = False

        # 已投注文件
        self.placed_path_var = tk.StringVar(value="")
        self.records_lock = threading.Lock()

        self._build_ui()
        self.on_load_config(initial=True)

    # ---------- UI ----------
    def _build_ui(self):
        top = ttk.Frame(self); top.pack(fill="x", padx=8, pady=6)
        ttk.Label(top, text="配置文件:").pack(side="left")
        self.cfg_path_var = tk.StringVar(value="config.json")
        ttk.Entry(top, textvariable=self.cfg_path_var, width=50).pack(side="left", padx=4)
        ttk.Button(top, text="加载配置", command=self.on_load_config).pack(side="left", padx=4)
        ttk.Button(top, text="保存配置", command=self.on_save_config).pack(side="left", padx=4)
        ttk.Button(top, text="另存为…", command=self.on_saveas_config).pack(side="left", padx=4)
        ttk.Button(top, text="登录所选账号", command=self.on_login_clicked).pack(side="left", padx=8)

        sel = ttk.Frame(self); sel.pack(fill="x", padx=8, pady=4)
        ttk.Label(sel, text="选择站点：").pack(side="left")
        self.site_var = tk.StringVar()
        self.site_combo = ttk.Combobox(sel, textvariable=self.site_var, state="readonly", width=26)
        self.site_combo.pack(side="left", padx=4)
        self.site_combo.bind("<<ComboboxSelected>>", lambda e: self._on_site_changed())

        ttk.Label(sel, text="选择账号：").pack(side="left", padx=(12, 0))
        self.acc_var = tk.StringVar()
        self.acc_combo = ttk.Combobox(sel, textvariable=self.acc_var, state="readonly", width=26)
        self.acc_combo.pack(side="left", padx=4)
        self.acc_combo.bind("<<ComboboxSelected>>", lambda e: self._on_account_changed())

        ttk.Label(sel, text="投注间隔(秒)：").pack(side="left", padx=(18, 0))
        self.interval_var = tk.DoubleVar(value=6.0)
        self.interval_spin = ttk.Spinbox(sel, textvariable=self.interval_var, from_=0.0, to=120.0, increment=0.5, width=8)
        self.interval_spin.pack(side="left", padx=4)
        ttk.Button(sel, text="更新间隔", command=self.on_update_interval).pack(side="left", padx=6)

        ttk.Label(sel, text="下注金额(元)：").pack(side="left", padx=(18, 0))
        self.amount_var = tk.IntVar(value=1)
        self.amount_spin = ttk.Spinbox(sel, textvariable=self.amount_var, from_=1, to=100000, increment=1, width=10)
        self.amount_spin.pack(side="left", padx=4)
        ttk.Button(sel, text="更新金额", command=self.on_update_amount).pack(side="left", padx=6)

        ttk.Label(sel, text="状态：").pack(side="left", padx=(18,0))
        self.run_state_var = tk.StringVar(value="空闲")
        self.run_state_lbl = ttk.Label(sel, textvariable=self.run_state_var, foreground="#008000")
        self.run_state_lbl.pack(side="left")

        ttk.Button(sel, text="新增站点", command=self.on_add_site_dialog).pack(side="left", padx=8)
        ttk.Button(sel, text="新增账号", command=self.on_add_account_dialog).pack(side="left", padx=4)

        ttk.Button(sel, text="删除账号", command=self.on_delete_account).pack(side="left", padx=4)

        ttk.Button(sel, text="修改站点", command=self.on_edit_site_dialog).pack(side="left", padx=8)
        ttk.Button(sel, text="修改账号", command=self.on_edit_account_dialog).pack(side="left", padx=4)

        self.status_tree = ttk.Treeview(self, columns=("site","base","account","status","time"), show="headings", height=6)
        for c,w,t in [("site",160,"站点"),("base",260,"站点URL"),("account",160,"账号"),("status",120,"状态"),("time",180,"登录时间")]:
            self.status_tree.heading(c, text=t); self.status_tree.column(c, width=w, anchor="center")
        self.status_tree.pack(fill="x", padx=8, pady=6)

        mid = ttk.Panedwindow(self, orient="horizontal"); mid.pack(fill="both", expand=True, padx=8, pady=6)
        left = ttk.Frame(mid); mid.add(left, weight=3)
        ttk.Label(left, text="投注指令（首列序号，后为 3 个号码；可粘贴“香港1.txt”）：").pack(anchor="w")
        self.input_txt = tk.Text(left, height=16); self.input_txt.pack(fill="both", expand=True, pady=4)
        btns = ttk.Frame(left); btns.pack(fill="x", pady=4)
        ttk.Button(btns, text="从文件导入", command=self.on_import_file).pack(side="left", padx=4)
        ttk.Button(btns, text="投注所选账号", command=self.on_bet_clicked).pack(side="left", padx=8)
        self.btn_pause = ttk.Button(btns, text="暂停投注", command=self.on_pause_clicked, state="disabled")
        self.btn_pause.pack(side="left", padx=6)
        self.btn_resume = ttk.Button(btns, text="恢复投注", command=self.on_resume_clicked, state="disabled")
        self.btn_resume.pack(side="left", padx=6)

        ttk.Label(left, text="日志：").pack(anchor="w")
        self.log_txt = tk.Text(left, height=10, bg="#111", fg="#ddd"); self.log_txt.pack(fill="both", expand=True, pady=4)

        right = ttk.Frame(mid); mid.add(right, weight=2)

        # 右上：已投注文件路径与按钮
        file_row = ttk.Frame(right); file_row.pack(fill="x", pady=(0,4))
        ttk.Label(file_row, text="已投注记录文件：").pack(side="left")
        self.placed_entry = ttk.Entry(file_row, textvariable=self.placed_path_var, width=50)
        self.placed_entry.pack(side="left", padx=4)
        ttk.Button(file_row, text="浏览…", command=self.on_browse_placed).pack(side="left", padx=2)
        ttk.Button(file_row, text="保存已投注到文件", command=self.on_save_placed_file).pack(side="left", padx=6)
        ttk.Button(file_row, text="应用已投注编辑", command=self.on_apply_done_edit).pack(side="left", padx=2)
        ttk.Button(file_row, text="重新载入", command=self.on_reload_placed_file).pack(side="left", padx=2)

        ttk.Label(right, text="已投注（成功才会进入，可编辑）：").pack(anchor="w")
        self.done_txt = tk.Text(right, height=14); self.done_txt.pack(fill="both", expand=True, pady=4)

        stat_pane = ttk.Panedwindow(right, orient="vertical"); stat_pane.pack(fill="both", expand=True)
        num_frame = ttk.Frame(stat_pane); stat_pane.add(num_frame, weight=1)
        ttk.Label(num_frame, text="单号出现次数（降序）").pack(anchor="w")
        self.num_tree = ttk.Treeview(num_frame, columns=("number","count"), show="headings", height=8)
        self.num_tree.heading("number", text="号码"); self.num_tree.heading("count", text="次数")
        self.num_tree.column("number", width=80, anchor="center"); self.num_tree.column("count", width=80, anchor="center")
        self.num_tree.pack(fill="both", expand=True, pady=4)

        trip_frame = ttk.Frame(stat_pane); stat_pane.add(trip_frame, weight=1)
        ttk.Label(trip_frame, text="组合出现次数（降序，组合内部升序显示）").pack(anchor="w")
        self.trip_tree = ttk.Treeview(trip_frame, columns=("combo","count"), show="headings", height=8)
        self.trip_tree.heading("combo", text="组合"); self.trip_tree.heading("count", text="次数")
        self.trip_tree.column("combo", width=200, anchor="center"); self.trip_tree.column("count", width=80, anchor="center")
        self.trip_tree.pack(fill="both", expand=True, pady=4)

        cfg_frame = ttk.LabelFrame(self, text="配置编辑器（JSON 数组：每个对象=一个站点）")
        cfg_frame.pack(fill="both", expand=False, padx=8, pady=6)
        self.cfg_editor = tk.Text(cfg_frame, height=10); self.cfg_editor.pack(fill="both", expand=True, padx=6, pady=6)
        cfg_btns = ttk.Frame(cfg_frame); cfg_btns.pack(fill="x", padx=6, pady=(0,6))
        ttk.Button(cfg_btns, text="载入到编辑器", command=self.on_load_into_editor).pack(side="left", padx=4)
        ttk.Button(cfg_btns, text="应用编辑到UI（不保存）", command=self.on_apply_editor_to_ui).pack(side="left", padx=4)
        ttk.Button(cfg_btns, text="格式化JSON", command=self.on_pretty_editor).pack(side="left", padx=4)

    # ---------- 配置 <-> UI ----------
    def _rebuild_accounts_by_site(self):
        self.accounts_by_site = {s.site_name: s.accounts for s in self.sites}

    def _refresh_selectors(self):
        self.site_combo["values"] = [s.site_name for s in self.sites]
        if self.sites and not self.site_var.get():
            self.site_combo.current(0)
        self._on_site_changed()

    def _on_site_changed(self):
        site = self._get_current_site()
        if not site:
            self.acc_combo["values"] = []; self.acc_var.set(""); return
        self.acc_combo["values"] = [a.name for a in site.accounts]
        if site.accounts:
            self.acc_combo.current(0)
        self.interval_var.set(site.bet_interval_sec)
        self.amount_var.set(site.bet_amount)
        # 切换站点也算切换账号：更新默认已投注文件并载入
        self._on_account_changed()

    def _on_account_changed(self):
        site = self._get_current_site()
        acc  = self._get_current_account()
        if not (site and acc): return
        # 默认文件名
        default_path = default_placed_path(site.site_name, acc.name)
        self.placed_path_var.set(default_path)
        # 自动载入
        self.load_placed_file(default_path)

    def _refresh_status_table(self):
        for i in self.status_tree.get_children(): self.status_tree.delete(i)
        for s in self.sites:
            for a in s.accounts:
                st = self.states.get((s.site_name, a.name), SessionState())
                status = "已登录" if st.logged_in else ("未登录" if not st.last_error else "失败")
                ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.last_login_ts)) if st.last_login_ts else ""
                self.status_tree.insert("", "end", values=(s.site_name, s.base_origin, a.account, status, ts))

    def _get_current_site(self) -> Optional[SiteCfg]:
        name = self.site_var.get()
        for s in self.sites:
            if s.site_name == name: return s
        return None

    def _get_current_account(self) -> Optional[AccountCfg]:
        site = self._get_current_site()
        if not site: return None
        name = self.acc_var.get()
        for a in site.accounts:
            if a.name == name: return a
        return None

    def _sync_editor_from_sites(self):
        try:
            data = sites_to_jsonable(self.sites)
            text = json.dumps(data, ensure_ascii=False, indent=2)
            self.cfg_editor.delete("1.0", "end")
            self.cfg_editor.insert("1.0", text)
        except Exception as e:
            self.log(f"[config] 写入编辑器失败：{e}")

    def on_load_config(self, initial: bool=False):
        path = self.cfg_path_var.get()
        try:
            self.sites = load_sites_config(path)
            for s in self.sites:
                for a in s.accounts:
                    a.base_origin = s.base_origin; a.safecode = s.safecode
                    a.retries = s.retries; a.verify_ssl = s.verify_ssl
            self._rebuild_accounts_by_site()
            self.states = {(s.site_name, a.name): SessionState() for s in self.sites for a in s.accounts}
            self._refresh_selectors(); self._refresh_status_table(); self._sync_editor_from_sites()
            if not initial: self.log(f"[config] 已从 {path} 加载 {len(self.sites)} 个站点.")
        except FileNotFoundError:
            if not initial: messagebox.showerror("错误", f"找不到配置文件：{path}")
        except Exception as e:
            messagebox.showerror("错误", f"读取配置失败：{e}")

    def on_save_config(self):
        path = self.cfg_path_var.get()
        try:
            data = json.loads(self.cfg_editor.get("1.0", "end"))
            self.sites = [_dict_to_sitecfg(d) for d in data]
            for s in self.sites:
                for a in s.accounts:
                    a.base_origin = s.base_origin; a.safecode = s.safecode
                    a.retries = s.retries; a.verify_ssl = s.verify_ssl
            with open(path, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
            self._rebuild_accounts_by_site()
            self.states = {(s.site_name, a.name): SessionState() for s in self.sites for a in s.accounts}
            self._refresh_selectors(); self._refresh_status_table()
            self.log(f"[config] 已保存到 {path}")
        except Exception as e:
            messagebox.showerror("错误", f"保存失败：{e}")

    def on_saveas_config(self):
        fp = filedialog.asksaveasfilename(title="另存为配置", defaultextension=".json",
                                          filetypes=[("JSON","*.json"),("All","*.*")])
        if not fp: return
        try:
            data = json.loads(self.cfg_editor.get("1.0", "end"))
            with open(fp, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
            self.cfg_path_var.set(fp); self.log(f"[config] 已另存为 {fp}")
        except Exception as e:
            messagebox.showerror("错误", f"另存失败：{e}")

    def on_load_into_editor(self):
        self._sync_editor_from_sites(); self.log("[config] 已载入当前配置到编辑器")

    def on_apply_editor_to_ui(self):
        try:
            data = json.loads(self.cfg_editor.get("1.0", "end"))
            self.sites = [_dict_to_sitecfg(d) for d in data]
            for s in self.sites:
                for a in s.accounts:
                    a.base_origin = s.base_origin; a.safecode = s.safecode
                    a.retries = s.retries; a.verify_ssl = s.verify_ssl
            self._rebuild_accounts_by_site()
            self.states = {(s.site_name, a.name): SessionState() for s in self.sites for a in s.accounts}
            self._refresh_selectors(); self._refresh_status_table()
            self.log("[config] 已应用编辑器内容到 UI（未保存到文件）")
        except Exception as e:
            messagebox.showerror("错误", f"应用失败：{e}")

    def on_pretty_editor(self):
        try:
            data = json.loads(self.cfg_editor.get("1.0", "end"))
            text = json.dumps(data, ensure_ascii=False, indent=2)
            self.cfg_editor.delete("1.0", "end"); self.cfg_editor.insert("1.0", text)
            self.log("[config] 已格式化 JSON")
        except Exception as e:
            messagebox.showerror("错误", f"格式化失败：{e}")

    def on_update_interval(self):
        site = self._get_current_site()
        if not site: return messagebox.showwarning("提示", "请先选择站点")
        try:
            val = float(self.interval_var.get())
            if val < 0: raise ValueError("间隔不能为负")
            site.bet_interval_sec = val; self._sync_editor_from_sites()
            self.log(f"[config] 已更新站点“{site.site_name}”投注间隔为 {val:.2f} 秒（未保存）")
        except Exception as e:
            messagebox.showerror("错误", f"更新间隔失败：{e}")

    def on_update_amount(self):
        site = self._get_current_site()
        if not site: return messagebox.showwarning("提示", "请先选择站点")
        try:
            val = int(self.amount_var.get())
            if val <= 0: raise ValueError("金额必须为正整数")
            site.bet_amount = val; self._sync_editor_from_sites()
            self.log(f"[config] 已更新站点“{site.site_name}”下注金额为 {val}（未保存）")
        except Exception as e:
            messagebox.showerror("错误", f"更新金额失败：{e}")

    # ---------- 新增站点 / 新增账号（小面板） ----------
    def on_add_site_dialog(self):
        win = tk.Toplevel(self); win.title("新增站点"); win.transient(self); win.grab_set()
        frm = ttk.Frame(win, padding=10); frm.pack(fill="both", expand=True)

        v_name = tk.StringVar(); v_base = tk.StringVar(); v_safe = tk.StringVar()
        v_retries = tk.IntVar(value=5); v_ssl = tk.BooleanVar(value=True)
        v_interval = tk.DoubleVar(value=6.0); v_amount = tk.IntVar(value=1)
        v_handicap = tk.IntVar(value=1); v_group = tk.IntVar(value=47)

        def row(label, widget):
            r = ttk.Frame(frm); r.pack(fill="x", pady=4)
            ttk.Label(r, text=label, width=18, anchor="e").pack(side="left")
            widget.pack(side="left", fill="x", expand=True); return r
        row("站点名称(site_name)：", ttk.Entry(frm, textvariable=v_name, width=40))
        row("站点URL(base_origin)：", ttk.Entry(frm, textvariable=v_base, width=40))
        row("SafeCode：", ttk.Entry(frm, textvariable=v_safe, width=40))

        r3 = ttk.Frame(frm); r3.pack(fill="x", pady=4)
        ttk.Label(r3, text="重试次数：", width=18, anchor="e").pack(side="left")
        ttk.Spinbox(r3, textvariable=v_retries, from_=1, to=10, width=6).pack(side="left", padx=4)
        ttk.Checkbutton(r3, text="验证SSL", variable=v_ssl).pack(side="left", padx=12)

        r4 = ttk.Frame(frm); r4.pack(fill="x", pady=4)
        ttk.Label(r4, text="投注间隔(秒)：", width=18, anchor="e").pack(side="left")
        ttk.Spinbox(r4, textvariable=v_interval, from_=0.0, to=120.0, increment=0.5, width=8).pack(side="left", padx=4)

        r5 = ttk.Frame(frm); r5.pack(fill="x", pady=4)
        ttk.Label(r5, text="下注金额(元)：", width=18, anchor="e").pack(side="left")
        ttk.Spinbox(r5, textvariable=v_amount, from_=1, to=100000, increment=1, width=10).pack(side="left", padx=4)

        r6 = ttk.Frame(frm); r6.pack(fill="x", pady=4)
        ttk.Label(r6, text="HandicapId：", width=18, anchor="e").pack(side="left")
        ttk.Spinbox(r6, textvariable=v_handicap, from_=1, to=20, width=6).pack(side="left", padx=4)
        ttk.Label(r6, text="GroupId：", width=10, anchor="e").pack(side="left")
        ttk.Spinbox(r6, textvariable=v_group, from_=1, to=200, width=6).pack(side="left", padx=4)

        btns = ttk.Frame(frm); btns.pack(fill="x", pady=(8,0))
        def do_create():
            name, base, safe = v_name.get().strip(), v_base.get().strip(), v_safe.get().strip()
            if not name or not base or not safe: return messagebox.showwarning("提示", "必填项为空")
            if any(s.site_name == name for s in self.sites): return messagebox.showerror("错误", f"同名站点已存在：{name}")
            site = SiteCfg(site_name=name, base_origin=base, safecode=safe,
                           retries=int(v_retries.get()), verify_ssl=bool(v_ssl.get()),
                           bet_interval_sec=float(v_interval.get()), bet_amount=int(v_amount.get()),
                           handicap_id=int(v_handicap.get()), group_id=int(v_group.get()), accounts=[])
            self.sites.append(site); self._rebuild_accounts_by_site()
            self.states.update({(site.site_name, a.name): SessionState() for a in site.accounts})
            self._refresh_selectors(); self.site_var.set(name); self._on_site_changed()
            self._sync_editor_from_sites(); self.log(f"[config] 已新增站点：{name}（未保存）"); win.destroy()
        ttk.Button(btns, text="创建", command=do_create).pack(side="left", padx=4)
        ttk.Button(btns, text="取消", command=win.destroy).pack(side="left", padx=4)

    def on_add_account_dialog(self):
        site = self._get_current_site()
        if not site: return messagebox.showwarning("提示", "请先选择站点")
        win = tk.Toplevel(self); win.title(f"新增账号（站点：{site.site_name}）"); win.transient(self); win.grab_set()
        frm = ttk.Frame(win, padding=10); frm.pack(fill="both", expand=True)
        v_name = tk.StringVar(); v_acc = tk.StringVar(); v_pwd = tk.StringVar()
        def row(label, widget):
            r = ttk.Frame(frm); r.pack(fill="x", pady=4)
            ttk.Label(r, text=label, width=14, anchor="e").pack(side="left")
            widget.pack(side="left", fill="x", expand=True); return r
        row("显示名(name)：", ttk.Entry(frm, textvariable=v_name, width=32))
        row("登录账号(account)：", ttk.Entry(frm, textvariable=v_acc, width=32))
        r_pwd = ttk.Entry(frm, textvariable=v_pwd, width=32, show="*"); row("密码(password)：", r_pwd)

        btns = ttk.Frame(frm); btns.pack(fill="x", pady=(8,0))
        def do_create():
            nm, acc, pwd = (v_name.get().strip() or v_acc.get().strip()), v_acc.get().strip(), v_pwd.get().strip()
            if not acc or not pwd: return messagebox.showwarning("提示", "account/password 不能为空")
            if any(a.name == nm for a in site.accounts): return messagebox.showerror("错误", f"同名账号已存在：{nm}")
            a = AccountCfg(name=nm, account=acc, password=pwd, base_origin=site.base_origin,
                           safecode=site.safecode, retries=site.retries, verify_ssl=site.verify_ssl)
            site.accounts.append(a); self.states[(site.site_name, a.name)] = SessionState()
            self._rebuild_accounts_by_site(); self._refresh_selectors(); self.site_var.set(site.site_name)
            self._on_site_changed(); self.acc_var.set(a.name); self._refresh_status_table()
            self._sync_editor_from_sites(); self.log(f"[config] 已新增账号：{a.name}（未保存）"); win.destroy()
        ttk.Button(btns, text="创建", command=do_create).pack(side="left", padx=4)
        ttk.Button(btns, text="取消", command=win.destroy).pack(side="left", padx=4)

    def on_delete_account(self):
        site = self._get_current_site()
        if not site:
            messagebox.showwarning("提示", "请先选择站点")
            return

        acc_name = self.acc_var.get().strip()
        if not acc_name:
            messagebox.showwarning("提示", "请先选择账号")
            return

        # 若有投注任务在跑，先禁止删除，避免状态错乱
        if self.bet_running:
            messagebox.showwarning("提示", "当前有投注任务在进行，请先暂停/完成后再删除账号。")
            return

        acc = next((a for a in site.accounts if a.name == acc_name), None)
        if not acc:
            messagebox.showerror("错误", f"未找到账号：{acc_name}")
            return

        if not messagebox.askyesno(
                "确认删除",
                f"确定要从站点“{site.site_name}”删除账号“{acc_name}”吗？\n（此操作不会自动保存到配置文件，需点“保存配置”才会落盘）"
        ):
            return

        # 1) 从站点配置删除
        try:
            site.accounts.remove(acc)
        except ValueError:
            pass

        # 2) 删除对应的运行状态
        self.states.pop((site.site_name, acc_name), None)

        # 3) 刷新账号下拉与依赖 UI
        self._rebuild_accounts_by_site()

        if site.accounts:
            # 还有其他账号 -> 切换到第一个，并触发加载其已投注文件与缓存
            self.acc_combo["values"] = [a.name for a in site.accounts]
            self.acc_combo.current(0)
            self.acc_var.set(site.accounts[0].name)
            self._on_account_changed()
        else:
            # 没有账号了 -> 清空账号区与“已投注”区及统计
            self.acc_combo["values"] = []
            self.acc_var.set("")
            self.placed_path_var.set("")
            self.done_txt.delete("1.0", "end")
            self.already_set.clear()
            self.num_counts.clear()
            self.triplet_counts.clear()
            for tv in (self.num_tree, self.trip_tree):
                for i in tv.get_children():
                    tv.delete(i)

        # 4) 刷新状态表、同步配置编辑器（但不落盘）
        self._refresh_status_table()
        self._sync_editor_from_sites()

        self.log(f"[config] 已从站点“{site.site_name}”删除账号：{acc_name}（未保存）")

    def on_edit_site_dialog(self):
        site = self._get_current_site()
        if not site:
            messagebox.showwarning("提示", "请先选择站点")
            return
        if self.bet_running:
            messagebox.showwarning("提示", "当前有投注任务在进行，完成或暂停后再修改站点。")
            return

        win = tk.Toplevel(self);
        win.title(f"修改站点（{site.site_name}）");
        win.transient(self);
        win.grab_set()
        frm = ttk.Frame(win, padding=10);
        frm.pack(fill="both", expand=True)

        # 绑定当前值
        old_name = site.site_name
        v_name = tk.StringVar(value=site.site_name)
        v_base = tk.StringVar(value=site.base_origin)
        v_safe = tk.StringVar(value=site.safecode)
        v_retries = tk.IntVar(value=site.retries)
        v_ssl = tk.BooleanVar(value=site.verify_ssl)
        v_interval = tk.DoubleVar(value=site.bet_interval_sec)
        v_amount = tk.IntVar(value=site.bet_amount)
        v_handicap = tk.IntVar(value=site.handicap_id)
        v_group = tk.IntVar(value=site.group_id)

        def row(label, widget):
            r = ttk.Frame(frm);
            r.pack(fill="x", pady=4)
            ttk.Label(r, text=label, width=18, anchor="e").pack(side="left")
            widget.pack(side="left", fill="x", expand=True);
            return r

        row("站点名称(site_name)：", ttk.Entry(frm, textvariable=v_name, width=40))
        row("站点URL(base_origin)：", ttk.Entry(frm, textvariable=v_base, width=40))
        row("SafeCode：", ttk.Entry(frm, textvariable=v_safe, width=40))

        r3 = ttk.Frame(frm);
        r3.pack(fill="x", pady=4)
        ttk.Label(r3, text="重试次数：", width=18, anchor="e").pack(side="left")
        ttk.Spinbox(r3, textvariable=v_retries, from_=1, to=10, width=6).pack(side="left", padx=4)
        ttk.Checkbutton(r3, text="验证SSL", variable=v_ssl).pack(side="left", padx=12)

        r4 = ttk.Frame(frm);
        r4.pack(fill="x", pady=4)
        ttk.Label(r4, text="投注间隔(秒)：", width=18, anchor="e").pack(side="left")
        ttk.Spinbox(r4, textvariable=v_interval, from_=0.0, to=120.0, increment=0.5, width=8).pack(side="left", padx=4)

        r5 = ttk.Frame(frm);
        r5.pack(fill="x", pady=4)
        ttk.Label(r5, text="下注金额(元)：", width=18, anchor="e").pack(side="left")
        ttk.Spinbox(r5, textvariable=v_amount, from_=1, to=100000, increment=1, width=10).pack(side="left", padx=4)

        r6 = ttk.Frame(frm);
        r6.pack(fill="x", pady=4)
        ttk.Label(r6, text="HandicapId：", width=18, anchor="e").pack(side="left")
        ttk.Spinbox(r6, textvariable=v_handicap, from_=1, to=20, width=6).pack(side="left", padx=4)
        ttk.Label(r6, text="GroupId：", width=10, anchor="e").pack(side="left")
        ttk.Spinbox(r6, textvariable=v_group, from_=1, to=200, width=6).pack(side="left", padx=4)

        btns = ttk.Frame(frm);
        btns.pack(fill="x", pady=(10, 0))

        def do_save():
            new_name = v_name.get().strip()
            new_base = v_base.get().strip()
            new_safe = v_safe.get().strip()
            if not new_name or not new_base or not new_safe:
                messagebox.showwarning("提示", "站点名称 / 站点URL / SafeCode 不能为空")
                return
            if new_name != old_name and any(s.site_name == new_name for s in self.sites):
                messagebox.showerror("错误", f"已存在重名站点：{new_name}")
                return

            # 更新站点
            site.site_name = new_name
            site.base_origin = new_base
            site.safecode = new_safe
            site.retries = int(v_retries.get())
            site.verify_ssl = bool(v_ssl.get())
            site.bet_interval_sec = float(v_interval.get())
            site.bet_amount = int(v_amount.get())
            site.handicap_id = int(v_handicap.get())
            site.group_id = int(v_group.get())

            # 同步到其下所有账号（这些字段以前即跟随站点）
            for a in site.accounts:
                a.base_origin = site.base_origin
                a.safecode = site.safecode
                a.retries = site.retries
                a.verify_ssl = site.verify_ssl

            # 若改名，重建 self.states 的键
            if new_name != old_name:
                new_states = {}
                for (sname, aname), st in self.states.items():
                    key = (new_name if sname == old_name else sname, aname)
                    new_states[key] = st
                self.states = new_states
                if self.site_var.get() == old_name:
                    self.site_var.set(new_name)

            # 刷新 UI & 编辑器
            self._rebuild_accounts_by_site()
            self._refresh_selectors()
            self._refresh_status_table()
            self._sync_editor_from_sites()

            # 让默认“已投注文件名”与选择状态同步
            if self._get_current_site() and self._get_current_account():
                self._on_account_changed()

            self.log(f"[config] 站点已更新：{site.site_name}（未保存）")
            win.destroy()

        ttk.Button(btns, text="保存", command=do_save).pack(side="left", padx=4)
        ttk.Button(btns, text="取消", command=win.destroy).pack(side="left", padx=4)

    def on_edit_account_dialog(self):
        site = self._get_current_site()
        if not site:
            messagebox.showwarning("提示", "请先选择站点")
            return
        acc = self._get_current_account()
        if not acc:
            messagebox.showwarning("提示", "请先选择账号")
            return
        if self.bet_running:
            messagebox.showwarning("提示", "当前有投注任务在进行，完成或暂停后再修改账号。")
            return

        win = tk.Toplevel(self);
        win.title(f"修改账号（{acc.name}）");
        win.transient(self);
        win.grab_set()
        frm = ttk.Frame(win, padding=10);
        frm.pack(fill="both", expand=True)

        old_name = acc.name
        v_name = tk.StringVar(value=acc.name)
        v_account = tk.StringVar(value=acc.account)
        v_pwd = tk.StringVar(value=acc.password)

        def row(label, widget):
            r = ttk.Frame(frm);
            r.pack(fill="x", pady=4)
            ttk.Label(r, text=label, width=16, anchor="e").pack(side="left")
            widget.pack(side="left", fill="x", expand=True);
            return r

        row("显示名(name)：", ttk.Entry(frm, textvariable=v_name, width=32))
        row("登录账号(account)：", ttk.Entry(frm, textvariable=v_account, width=32))
        row("密码(password)：", ttk.Entry(frm, textvariable=v_pwd, width=32, show="*"))

        btns = ttk.Frame(frm);
        btns.pack(fill="x", pady=(10, 0))

        def do_save():
            new_name = (v_name.get().strip() or v_account.get().strip())
            new_acc = v_account.get().strip()
            new_pwd = v_pwd.get().strip()
            if not new_acc:
                messagebox.showwarning("提示", "登录账号(account) 不能为空")
                return
            if new_name != old_name and any(a.name == new_name for a in site.accounts):
                messagebox.showerror("错误", f"该站点下已存在同名账号：{new_name}")
                return

            # 更新账号
            acc.name = new_name
            acc.account = new_acc
            acc.password = new_pwd

            # 若改名，迁移运行状态
            if new_name != old_name:
                st = self.states.pop((site.site_name, old_name), SessionState())
                self.states[(site.site_name, new_name)] = st

            # 刷新 UI
            self._rebuild_accounts_by_site()
            self._refresh_selectors()
            self._refresh_status_table()

            # 如果当前正选中这个账号，更新选择并刷新“已投注”默认文件与缓存
            if self.acc_var.get() == old_name:
                self.acc_var.set(new_name)
                self._on_account_changed()

            # 同步到编辑器（不落盘）
            self._sync_editor_from_sites()
            self.log(f"[config] 账号已更新：{acc.name}（未保存）")
            win.destroy()

        ttk.Button(btns, text="保存", command=do_save).pack(side="left", padx=4)
        ttk.Button(btns, text="取消", command=win.destroy).pack(side="left", padx=4)

    # ---------- 登录 ----------
    def on_login_clicked(self):
        site = self._get_current_site()
        if not site: return messagebox.showwarning("提示", "请先选择站点")
        ac = self._get_current_account()
        if not ac: return messagebox.showwarning("提示", "请先选择账号")

        def worker():
            self.log(f"[login] 正在登录 {site.site_name} / {ac.name} ...")
            st = login_flow(ac, self.log)
            self.states[(site.site_name, ac.name)] = st
            if st.logged_in:
                self.log(f"[login] 登录成功，线路：{st.draw_base}")
            else:
                self.log(f"[login] 登录失败：{st.last_error}")
            self.after(0, self._refresh_status_table)
        threading.Thread(target=worker, daemon=True).start()

    # ---------- 投注 ----------
    def on_import_file(self):
        fp = filedialog.askopenfilename(title="选择投注指令文件",
                                        filetypes=[("Text","*.txt;*.csv;*.tsv;*.log;*.lst;*.*")])
        if not fp: return
        try:
            with open(fp, "r", encoding="utf-8") as f: content = f.read()
            self.input_txt.delete("1.0","end"); self.input_txt.insert("1.0", content)
            self.log(f"[import] 已导入文件：{fp}")
        except Exception as e:
            messagebox.showerror("错误", f"读取文件失败：{e}")

    def on_bet_clicked(self):
        if self.bet_thread and self.bet_thread.is_alive():
            return messagebox.showwarning("提示", "当前已有投注任务在进行。可使用“暂停/恢复”。")

        site = self._get_current_site()
        if not site: return messagebox.showwarning("提示", "请先选择站点")
        ac = self._get_current_account()
        if not ac: return messagebox.showwarning("提示", "请先选择账号")

        tasks = parse_bet_lines(self.input_txt.get("1.0", "end").strip())
        if not tasks: return messagebox.showinfo("提示", "未解析到任何投注指令")

        st = self.states.get((site.site_name, ac.name)) or SessionState()
        self.states[(site.site_name, ac.name)] = st
        client = RealBetClient(site, ac, st, self.log)
        base_interval = float(site.bet_interval_sec or 6.0)

        # 置运行状态 & 按钮
        self.bet_running = True
        self.pause_event.set()
        self.run_state_var.set("运行中")
        self.btn_pause["state"] = "normal"
        self.btn_resume["state"] = "disabled"

        def _sleep_with_pause(seconds: float):
            end = time.time() + seconds
            while True:
                remaining = end - time.time()
                if remaining <= 0:
                    break
                self.pause_event.wait()  # 清除=暂停；设置=继续
                time.sleep(min(0.2, max(0.0, remaining)))

        def worker():
            placed = 0
            for idx, (serial, nums) in enumerate(tasks):
                self.pause_event.wait()  # 若暂停则等待

                key = (serial, nums)
                if key in self.already_set:
                    self.log(f"[bet] 跳过重复：序号{serial} -> {nums}")
                    continue

                for attempt in range(2):  # 允许一次自动重登后的重试
                    self.pause_event.wait()
                    res = client.place_bet(serial, nums)
                    if res.get("Status") == 1:
                        self.already_set.add(key)
                        self._append_done_line(serial, nums, res.get("OrderId"), save_to_file=True)
                        self._update_stats(nums)
                        placed += 1
                        break
                    else:
                        msg = res.get("Message", "UnknownError")
                        self.log(f"[bet] 下单失败（{msg}），尝试重登再试 ...")
                else:
                    self.log(f"[bet] 指令最终失败：序号{serial} -> {nums}")

                if idx < len(tasks) - 1:
                    jitter = random.uniform(-1.0, 1.0)
                    delay = max(0.0, base_interval + jitter)
                    self.log(f"[bet] 等待 {delay:.2f} 秒后继续 ...")
                    _sleep_with_pause(delay)

            self.log(f"[bet] 完成。成功 {placed} 条 / 共 {len(tasks)} 条。")
            self.bet_running = False
            self.run_state_var.set("空闲")
            self.btn_pause["state"] = "disabled"
            self.btn_resume["state"] = "disabled"

        self.bet_thread = threading.Thread(target=worker, daemon=True)
        self.bet_thread.start()

    # 暂停 / 恢复
    def on_pause_clicked(self):
        if not self.bet_running or not self.pause_event.is_set():
            return
        self.pause_event.clear()
        self.run_state_var.set("已暂停")
        self.btn_pause["state"] = "disabled"
        self.btn_resume["state"] = "normal"
        self.log("[bet] 已暂停。")

    def on_resume_clicked(self):
        if not self.bet_running or self.pause_event.is_set():
            return
        self.pause_event.set()
        self.run_state_var.set("运行中")
        self.btn_pause["state"] = "normal"
        self.btn_resume["state"] = "disabled"
        self.log("[bet] 已恢复。")

    # ---------- 已投注记录：文件/缓存 ----------
    def on_browse_placed(self):
        fp = filedialog.asksaveasfilename(title="选择/创建已投注记录文件",
                                          defaultextension=".txt",
                                          filetypes=[("Text","*.txt"),("All","*.*")])
        if not fp: return
        self.placed_path_var.set(fp)
        # 载入（如果已存在）
        self.load_placed_file(fp)

    def on_save_placed_file(self):
        path = self.placed_path_var.get().strip()
        if not path:
            messagebox.showwarning("提示","请先设置已投注记录文件路径"); return
        try:
            with self.records_lock:
                content = self.done_txt.get("1.0","end")
                with open(path, "w", encoding="utf-8") as f:
                    f.write(content)
            self.log(f"[placed] 已保存到 {path}")
            # 保存后刷新缓存
            self.rebuild_cache_from_done_text()
        except Exception as e:
            messagebox.showerror("错误", f"保存失败：{e}")

    def on_reload_placed_file(self):
        path = self.placed_path_var.get().strip()
        if not path:
            messagebox.showwarning("提示","请先设置已投注记录文件路径"); return
        self.load_placed_file(path)

    def on_apply_done_edit(self):
        self.rebuild_cache_from_done_text()
        self.log("[placed] 已应用已投注编辑（缓存&统计已刷新）。")

    def load_placed_file(self, path: str):
        """读取文件 -> 显示到文本框 -> 重建缓存/统计"""
        try:
            text = ""
            if os.path.exists(path):
                with open(path, "r", encoding="utf-8") as f:
                    text = f.read()
                self.log(f"[placed] 已从 {path} 载入 {len(text.splitlines())} 行记录")
            else:
                self.log(f"[placed] 文件不存在，将在首次投注成功时创建：{path}")
            self.done_txt.delete("1.0","end")
            if text:
                self.done_txt.insert("1.0", text)
            # 每次载入后都刷新缓存/统计
            self.rebuild_cache_from_done_text()
        except Exception as e:
            messagebox.showerror("错误", f"读取已投注文件失败：{e}")

    def rebuild_cache_from_done_text(self):
        """把已投注文本解析到去重集合，并重算统计（这样删除一行即可允许后续再次投注）。"""
        self.already_set.clear()
        self.num_counts.clear()
        self.triplet_counts.clear()

        lines = self.done_txt.get("1.0","end").splitlines()
        for ln in lines:
            tup = parse_done_line(ln)
            if not tup: continue
            serial, nums = tup
            key = (serial, nums)
            self.already_set.add(key)
            # 统计
            for n in nums:
                self.num_counts[n] = self.num_counts.get(n, 0) + 1
            combo = tuple(sorted(nums))
            self.triplet_counts[combo] = self.triplet_counts.get(combo, 0) + 1

        # 刷新右下两张表
        for tv in (self.num_tree, self.trip_tree):
            for i in tv.get_children(): tv.delete(i)
        for n,c in sorted(self.num_counts.items(), key=lambda x:(-x[1], x[0])):
            self.num_tree.insert("", "end", values=(n,c))
        def fmt_combo(t): return f"{t[0]} {t[1]} {t[2]}"
        for combo,c in sorted(self.triplet_counts.items(), key=lambda x:(-x[1], x[0])):
            self.trip_tree.insert("", "end", values=(fmt_combo(combo), c))

    def _append_done_line(self, serial: int, nums: Tuple[int, int, int], order_id: Optional[str], save_to_file: bool=False):
        line = f"{serial}\t{nums[0]} {nums[1]} {nums[2]}"
        if order_id: line += f"\t#{order_id}"
        self.done_txt.insert("end", line + "\n"); self.done_txt.see("end")

        if save_to_file:
            path = self.placed_path_var.get().strip()
            if not path:
                # 若未指定则使用当前默认
                site = self._get_current_site(); acc = self._get_current_account()
                if site and acc:
                    path = default_placed_path(site.site_name, acc.name)
                    self.placed_path_var.set(path)
            try:
                with self.records_lock:
                    with open(path, "a", encoding="utf-8") as f:
                        f.write(line + "\n")
                # 同步统计（已在外层 done 后做过 num/trip 统计；这里不重复）
            except Exception as e:
                self.log(f"[placed] 追加保存失败：{e}")

    def _update_stats(self, nums: Tuple[int, int, int]):
        for n in nums: self.num_counts[n] = self.num_counts.get(n, 0) + 1
        combo = tuple(sorted(nums)); self.triplet_counts[combo] = self.triplet_counts.get(combo, 0) + 1
        for tv in (self.num_tree, self.trip_tree):
            for i in tv.get_children(): tv.delete(i)
        for n,c in sorted(self.num_counts.items(), key=lambda x:(-x[1], x[0])):
            self.num_tree.insert("", "end", values=(n,c))
        def fmt_combo(t): return f"{t[0]} {t[1]} {t[2]}"
        for combo,c in sorted(self.triplet_counts.items(), key=lambda x:(-x[1], x[0])):
            self.trip_tree.insert("", "end", values=(fmt_combo(combo), c))

    # ---------- 状态/统计/日志 ----------
    def log(self, s: str):
        self.log_txt.insert("end", s + "\n"); self.log_txt.see("end")

# ------------------ 启动 ------------------
if __name__ == "__main__":
    App().mainloop()
